// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

/**
 * Slide mode displays a single image and has a set of controls to navigate
 * between the images and to edit an image.
 *
 * @param {!HTMLElement} container Main container element.
 * @param {!HTMLElement} content Content container element.
 * @param {!HTMLElement} topToolbar Top toolbar element.
 * @param {!HTMLElement} bottomToolbar Toolbar element.
 * @param {!ImageEditor.Prompt} prompt Prompt.
 * @param {!ErrorBanner} errorBanner Error banner.
 * @param {!cr.ui.ArrayDataModel} dataModel Data model.
 * @param {!cr.ui.ListSelectionModel} selectionModel Selection model.
 * @param {!MetadataModel} metadataModel
 * @param {!ThumbnailModel} thumbnailModel
 * @param {!Object} context Context.
 * @param {!VolumeManagerWrapper} volumeManager Volume manager.
 * @param {function(function())} toggleMode Function to toggle the Gallery mode.
 * @param {function(string):string} displayStringFunction String formatting
 *     function.
 * @param {!DimmableUIController} dimmableUIController Dimmable UI controller.
 * @constructor
 * @struct
 * @extends {cr.EventTarget}
 */
function SlideMode(container, content, topToolbar, bottomToolbar, prompt,
    errorBanner, dataModel, selectionModel, metadataModel, thumbnailModel,
    context, volumeManager, toggleMode, displayStringFunction,
    dimmableUIController) {
  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.container_ = container;

  /**
   * @type {!Document}
   * @private
   * @const
   */
  this.document_ = assert(container.ownerDocument);

  /**
   * @type {!HTMLElement}
   * @const
   */
  this.content = content;

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.topToolbar_ = topToolbar;

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.bottomToolbar_ = bottomToolbar;

  /**
   * @type {!ImageEditor.Prompt}
   * @private
   * @const
   */
  this.prompt_ = prompt;

  /**
   * @type {!ErrorBanner}
   * @private
   * @const
   */
  this.errorBanner_ = errorBanner;

  /**
   * @type {!cr.ui.ArrayDataModel}
   * @private
   * @const
   */
  this.dataModel_ = dataModel;

  /**
   * @type {!cr.ui.ListSelectionModel}
   * @private
   * @const
   */
  this.selectionModel_ = selectionModel;

  /**
   * @type {!Object}
   * @private
   * @const
   */
  this.context_ = context;

  /**
   * @type {!VolumeManagerWrapper}
   * @private
   * @const
   */
  this.volumeManager_ = volumeManager;

  /**
   * @type {function(function())}
   * @private
   * @const
   */
  this.toggleMode_ = toggleMode;

  /**
   * @type {function(string):string}
   * @private
   * @const
   */
  this.displayStringFunction_ = displayStringFunction;

  /**
   * @private {!DimmableUIController}
   * @const
   */
  this.dimmableUIController_ = dimmableUIController;

  /**
   * @type {function(this:SlideMode)}
   * @private
   * @const
   */
  this.onSelectionBound_ = this.onSelection_.bind(this);

  /**
   * @type {function(this:SlideMode,!Event)}
   * @private
   * @const
   */
  this.onSpliceBound_ = this.onSplice_.bind(this);

  /**
   * Unique numeric key, incremented per each load attempt used to discard
   * old attempts. This can happen especially when changing selection fast or
   * Internet connection is slow.
   *
   * @type {number}
   * @private
   */
  this.currentUniqueKey_ = 0;

  /**
   * @type {number}
   * @private
   */
  this.sequenceDirection_ = 0;

  /**
   * @type {number}
   * @private
   */
  this.sequenceLength_ = 0;

  /**
   * @type {Array<number>}
   * @private
   */
  this.savedSelection_ = null;

  /**
   * @type {GalleryItem}
   * @private
   */
  this.displayedItem_ = null;

  /**
   * @type {?number}
   * @private
   */
  this.slideHint_ = null;

  /**
   * @type {boolean}
   * @private
   */
  this.active_ = false;

  /**
   * @private {Gallery.SubMode}
   */
  this.subMode_ = Gallery.SubMode.BROWSE;

  /**
   * @type {boolean}
   * @private
   */
  this.leaveAfterSlideshow_ = false;

  /**
   * @type {boolean}
   * @private
   */
  this.fullscreenBeforeSlideshow_ = false;

  /**
   * @type {?number}
   * @private
   */
  this.slideShowTimeout_ = null;

  /**
   * @private {string|undefined}
   */
  this.loadingItemUrl_ = undefined;

  /**
   * @private {number}
   */
  this.progressBarTimer_ = 0;

  /**
   * @type {?number}
   * @private
   */
  this.spinnerTimer_ = null;

  window.addEventListener('resize', this.onResize_.bind(this));

  // ----------------------------------------------------------------
  // Initializes the UI.

  /**
   * Container for displayed image.
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.imageContainer_ = util.createChild(queryRequiredElement(
      '.content', this.document_), 'image-container');

  this.document_.addEventListener('click', this.onDocumentClick_.bind(this));

  /**
   * Overwrite options and info bubble.
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.options_ = queryRequiredElement('.options', this.bottomToolbar_);

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.savedLabel_ = queryRequiredElement('.saved', this.options_);

  /**
   * @private {!PaperCheckboxElement}
   * @const
   */
  this.overwriteOriginalCheckbox_ = /** @type {!PaperCheckboxElement} */
      (queryRequiredElement('.overwrite-original', this.options_));
  this.overwriteOriginalCheckbox_.addEventListener('change',
      this.onOverwriteOriginalCheckboxChanged_.bind(this));

  /**
   * @private {!FilesToast}
   * @const
   */
  this.filesToast_ = /** @type {!FilesToast} */
      (queryRequiredElement('files-toast'));

  /**
   * @private {!HTMLElement}
   * @const
   */
  this.bubble_ = queryRequiredElement('.bubble', this.bottomToolbar_);

  var bubbleContent = queryRequiredElement('.content', this.bubble_);
  // GALLERY_OVERWRITE_BUBBLE contains <br> tag inside message.
  bubbleContent.innerHTML = strf('GALLERY_OVERWRITE_BUBBLE');

  var bubbleClose = queryRequiredElement('.close-x', this.bubble_);
  bubbleClose.addEventListener('click', this.onCloseBubble_.bind(this));

  /**
   * Ribbon and related controls.
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.arrowBox_ = util.createChild(this.container_, 'arrow-box');

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.arrowLeft_ = util.createChild(
      this.arrowBox_, 'arrow left tool dimmable');
  this.arrowLeft_.addEventListener('click',
      this.advanceManually.bind(this, -1));
  util.createChild(this.arrowLeft_);

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.arrowRight_ = util.createChild(
      this.arrowBox_, 'arrow right tool dimmable');
  this.arrowRight_.addEventListener('click',
      this.advanceManually.bind(this, 1));
  util.createChild(this.arrowRight_);

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.ribbonSpacer_ = queryRequiredElement('.ribbon-spacer',
      this.bottomToolbar_);

  /**
   * @type {!Ribbon}
   * @private
   * @const
   */
  this.ribbon_ = new Ribbon(this.document_, window, this.dataModel_,
      this.selectionModel_, thumbnailModel);
  this.ribbonSpacer_.appendChild(this.ribbon_);

  util.createChild(this.container_, 'spinner');

  /**
   * @type {!HTMLElement}
   * @const
   */
  var slideShowButton = queryRequiredElement(
      'button.slideshow', this.topToolbar_);
  slideShowButton.addEventListener('click',
      this.startSlideshow.bind(this, SlideMode.SLIDESHOW_INTERVAL_FIRST));

  /**
   * @private {!PaperProgressElement}
   * @const
   */
  this.progressBar_ = /** @type {!PaperProgressElement} */
      (queryRequiredElement('#progress-bar', document));
  chrome.fileManagerPrivate.onFileTransfersUpdated.addListener(
      this.updateProgressBar_.bind(this));

  /**
   * @type {!HTMLElement}
   * @const
   */
  var slideShowToolbar = util.createChild(
      this.container_, 'tool slideshow-toolbar');
  util.createChild(slideShowToolbar, 'slideshow-play').
      addEventListener('click', this.toggleSlideshowPause_.bind(this));
  util.createChild(slideShowToolbar, 'slideshow-end').
      addEventListener('click', this.stopSlideshow_.bind(this));

  // Editor.
  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.editButton_ = queryRequiredElement('button.edit', this.topToolbar_);
  GalleryUtil.decorateMouseFocusHandling(this.editButton_);
  this.editButton_.addEventListener('click', this.toggleEditor.bind(this));

  /**
   * @private {!FilesToggleRipple}
   * @const
   */
  this.editButtonToggleRipple_ = /** @type {!FilesToggleRipple} */
      (assert(this.editButton_.querySelector('files-toggle-ripple')));

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.printButton_ = queryRequiredElement('button.print', this.topToolbar_);
  GalleryUtil.decorateMouseFocusHandling(this.printButton_);
  this.printButton_.addEventListener('click', this.print_.bind(this));

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.editBarSpacer_ = queryRequiredElement('.edit-bar-spacer',
      this.bottomToolbar_);

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.editBarMain_ = util.createChild(this.editBarSpacer_, 'edit-main');

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.editBarMode_ = util.createChild(this.container_, 'edit-modal');

  /**
   * @type {!HTMLElement}
   * @private
   * @const
   */
  this.editBarModeWrapper_ = util.createChild(
      this.editBarMode_, 'edit-modal-wrapper dimmable');
  this.editBarModeWrapper_.hidden = true;

  /**
   * Objects supporting image display and editing.
   * @type {!Viewport}
   * @private
   * @const
   */
  this.viewport_ = new Viewport(window);
  this.viewport_.addEventListener('resize', this.onViewportResize_.bind(this));

  /**
   * @type {!ImageView}
   * @private
   * @const
   */
  this.imageView_ = new ImageView(
      this.imageContainer_,
      this.viewport_,
      metadataModel);

  /**
   * @type {!ImageEditor}
   * @private
   * @const
   */
  this.editor_ = new ImageEditor(
      this.viewport_,
      this.imageView_,
      this.prompt_,
      {
        root: this.container_,
        image: this.imageContainer_,
        toolbar: this.editBarMain_,
        mode: this.editBarModeWrapper_
      },
      SlideMode.EDITOR_MODES,
      this.displayStringFunction_);
  this.editor_.addEventListener('exit-clicked', this.onExitClicked_.bind(this));

  /**
   * @type {!TouchHandler}
   * @private
   * @const
   */
  this.touchHandlers_ = new TouchHandler(this.imageContainer_, this);
}

/**
 * List of available editor modes.
 * @type {!Array<ImageEditor.Mode>}
 * @const
 */
SlideMode.EDITOR_MODES = [
  new ImageEditor.Mode.InstantAutofix(),
  new ImageEditor.Mode.Crop(),
  new ImageEditor.Mode.Exposure(),
  new ImageEditor.Mode.OneClick(
      'rotate_left', 'GALLERY_ROTATE_LEFT', new Command.Rotate(-1)),
  new ImageEditor.Mode.OneClick(
      'rotate_right', 'GALLERY_ROTATE_RIGHT', new Command.Rotate(1))
];

/**
 * Map of the key identifier and offset delta.
 * @enum {!Array<number>})
 * @const
 */
SlideMode.KEY_OFFSET_MAP = {
  'Up': [0, 20],
  'Down': [0, -20],
  'Left': [20, 0],
  'Right': [-20, 0]
};

/**
 * Returns editor warning message if it should be shown.
 * @param {!GalleryItem} item
 * @param {string} readonlyDirName Name of read only volume. Pass empty string
 *     if volume is writable.
 * @param {!DirectoryEntry} fallbackSaveDirectory
 * @return {!Promise<?string>} Warning message. null if no warning message
 *     should be shown.
 */
SlideMode.getEditorWarningMessage = function(
    item, readonlyDirName, fallbackSaveDirectory) {
  var isReadOnlyVolume = !!readonlyDirName;
  var isWritableFormat = item.isWritableFormat();

  if (isReadOnlyVolume && !isWritableFormat) {
    return item.getCopyName(fallbackSaveDirectory).then(function(copyName) {
      return strf('GALLERY_READONLY_AND_NON_WRITABLE_FORMAT_WARNING',
          readonlyDirName, copyName);
    });
  } else if (isReadOnlyVolume) {
    return Promise.resolve(/** @type {?string} */
        (strf('GALLERY_READONLY_WARNING', readonlyDirName)));
  } else if (!isWritableFormat) {
    var entry = item.getEntry();
    return new Promise(entry.getParent.bind(entry)).then(function(parentDir) {
      return item.getCopyName(parentDir);
    }).then(function(copyName) {
      return strf('GALLERY_NON_WRITABLE_FORMAT_WARNING', copyName);
    });
  } else {
    return Promise.resolve(/** @type {?string} */ (null));
  }
};

/**
 * SlideMode extends cr.EventTarget.
 */
SlideMode.prototype.__proto__ = cr.EventTarget.prototype;

/**
 * Handles exit-clicked event.
 * @private
 */
SlideMode.prototype.onExitClicked_ = function() {
  if (this.isEditing())
    this.toggleEditor();
};

/**
 * @return {string} Mode name.
 */
SlideMode.prototype.getName = function() { return 'slide'; };

/**
 * @return {string} Mode title.
 */
SlideMode.prototype.getTitle = function() { return 'GALLERY_SLIDE'; };

/**
 * @return {!Viewport} Viewport.
 */
SlideMode.prototype.getViewport = function() { return this.viewport_; };

/**
 * Load items, display the selected item.
 * @param {ImageRect} zoomFromRect Rectangle for zoom effect.
 * @param {function()} displayCallback Called when the image is displayed.
 * @param {function()} loadCallback Called when the image is displayed.
 */
SlideMode.prototype.enter = function(
    zoomFromRect, displayCallback, loadCallback) {
  this.sequenceDirection_ = 0;
  this.sequenceLength_ = 0;

  // The latest |leave| call might have left the image animating. Remove it.
  this.unloadImage_();
  this.errorBanner_.clear();

  new Promise(function(fulfill) {
    // If the items are empty, just show the error message.
    if (this.getItemCount_() === 0) {
      this.displayedItem_ = null;
      this.errorBanner_.show('GALLERY_NO_IMAGES');
      fulfill();
      return;
    }

    // Remember the selection if it is empty or multiple. It will be restored
    // in |leave| if the user did not changing the selection manually.
    var currentSelection = this.selectionModel_.selectedIndexes;
    if (currentSelection.length === 1)
      this.savedSelection_ = null;
    else
      this.savedSelection_ = currentSelection;

    // Ensure valid single selection.
    // Note that the SlideMode object is not listening to selection change yet.
    this.select(Math.max(0, this.getSelectedIndex()));

    // Show the selected item ASAP, then complete the initialization
    // (loading the ribbon thumbnails can take some time).
    var selectedItem = this.getSelectedItem();
    this.displayedItem_ = selectedItem;

    // Load the image of the item.
    this.loadItem_(
        assert(selectedItem),
        zoomFromRect ?
            this.imageView_.createZoomEffect(zoomFromRect) :
            new ImageView.Effect.None(),
        displayCallback,
        function(loadType, delay) {
          fulfill(delay);
        });
  }.bind(this)).then(function(delay) {
    // Turn the mode active.
    this.active_ = true;
    ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);
    this.ribbon_.enable();

    // Register handlers.
    this.selectionModel_.addEventListener('change', this.onSelectionBound_);
    this.dataModel_.addEventListener('splice', this.onSpliceBound_);
    this.touchHandlers_.enabled = true;

    // Wait 1000ms after the animation is done, then prefetch the next image.
    this.requestPrefetch(1, delay + 1000);

    // Call load callback.
    if (loadCallback)
      loadCallback();
  }.bind(this)).catch(function(error) {
    console.error(error.stack, error);
  });
};

/**
 * Leave the mode.
 * @param {ImageRect} zoomToRect Rectangle for zoom effect.
 * @param {function()} callback Called when the image is committed and
 *   the zoom-out animation has started.
 */
SlideMode.prototype.leave = function(zoomToRect, callback) {
  var commitDone = function() {
    this.stopEditing_();
    this.stopSlideshow_();
    ImageUtil.setAttribute(this.arrowBox_, 'active', false);
    this.selectionModel_.removeEventListener(
        'change', this.onSelectionBound_);
    this.dataModel_.removeEventListener('splice', this.onSpliceBound_);
    this.ribbon_.disable();
    this.active_ = false;
    if (this.savedSelection_)
      this.selectionModel_.selectedIndexes = this.savedSelection_;
    this.unloadImage_(zoomToRect);
    callback();
  }.bind(this);

  this.viewport_.resetView();
  if (this.getItemCount_() === 0) {
    this.errorBanner_.clear();
    commitDone();
  } else {
    this.commitItem_(commitDone);
  }

  // Disable the slide-mode only buttons when leaving.
  this.editButton_.disabled = true;
  this.printButton_.disabled = true;

  // Disable touch operation.
  this.touchHandlers_.enabled = false;
};


/**
 * Execute an action when the editor is not busy.
 *
 * @param {function()} action Function to execute.
 */
SlideMode.prototype.executeWhenReady = function(action) {
  this.editor_.executeWhenReady(action);
};

/**
 * @return {boolean} True if the mode has active tools (that should not fade).
 */
SlideMode.prototype.hasActiveTool = function() {
  return this.isEditing();
};

/**
 * @return {number} Item count.
 * @private
 */
SlideMode.prototype.getItemCount_ = function() {
  return this.dataModel_.length;
};

/**
 * @param {number} index Index.
 * @return {GalleryItem} Item.
 */
SlideMode.prototype.getItem = function(index) {
  var item =
      /** @type {(GalleryItem|undefined)} */ (this.dataModel_.item(index));
  return item === undefined ? null : item;
};

/**
 * @return {number} Selected index.
 */
SlideMode.prototype.getSelectedIndex = function() {
  return this.selectionModel_.selectedIndex;
};

/**
 * @return {ImageRect} Screen rectangle of the selected image.
 */
SlideMode.prototype.getSelectedImageRect = function() {
  if (this.getSelectedIndex() < 0)
    return null;
  else
    return this.viewport_.getImageBoundsOnScreen();
};

/**
 * @return {GalleryItem} Selected item.
 */
SlideMode.prototype.getSelectedItem = function() {
  return this.getItem(this.getSelectedIndex());
};

/**
 * Toggles the full screen mode.
 * @private
 */
SlideMode.prototype.toggleFullScreen_ = function() {
  util.toggleFullScreen(this.context_.appWindow,
                        !util.isFullScreen(this.context_.appWindow));
};

/**
 * Selection change handler.
 *
 * Commits the current image and displays the newly selected image.
 * @private
 */
SlideMode.prototype.onSelection_ = function() {
  if (this.selectionModel_.selectedIndexes.length === 0)
    return;  // Ignore temporary empty selection.

  // Forget the saved selection if the user changed the selection manually.
  if (!this.isSlideshowOn_())
    this.savedSelection_ = null;

  if (this.getSelectedItem() === this.displayedItem_)
    return;  // Do not reselect.

  this.commitItem_(this.loadSelectedItem_.bind(this));
};

/**
 * Change the selection.
 *
 * @param {number} index New selected index.
 * @param {number=} opt_slideHint Slide animation direction (-1|1).
 */
SlideMode.prototype.select = function(index, opt_slideHint) {
  this.slideHint_ = opt_slideHint || null;
  this.selectionModel_.selectedIndex = index;
  this.selectionModel_.leadIndex = index;
};

/**
 * Load the selected item.
 *
 * @private
 */
SlideMode.prototype.loadSelectedItem_ = function() {
  var slideHint = this.slideHint_;
  this.slideHint_ = null;

  if (this.getSelectedItem() === this.displayedItem_)
    return;  // Do not reselect.

  var index = this.getSelectedIndex();
  if (index < 0)
    return;

  var displayedIndex = this.dataModel_.indexOf(this.displayedItem_);
  var step =
      slideHint || (displayedIndex > 0 ? index - displayedIndex : 1);

  if (Math.abs(step) != 1) {
    // Long leap, the sequence is broken, we have no good prefetch candidate.
    this.sequenceDirection_ = 0;
    this.sequenceLength_ = 0;
  } else if (this.sequenceDirection_ === step) {
    // Keeping going in sequence.
    this.sequenceLength_++;
  } else {
    // Reversed the direction. Reset the counter.
    this.sequenceDirection_ = step;
    this.sequenceLength_ = 1;
  }

  this.displayedItem_ = this.getSelectedItem();
  var selectedItem = assertInstanceof(this.getSelectedItem(), GalleryItem);

  function shouldPrefetch(loadType, step, sequenceLength) {
    // Never prefetch when selecting out of sequence.
    if (Math.abs(step) != 1)
      return false;

    // Always prefetch if the previous load was from cache.
    if (loadType === ImageView.LoadType.CACHED_FULL)
      return true;

    // Prefetch if we have been going in the same direction for long enough.
    return sequenceLength >= 3;
  }

  this.currentUniqueKey_++;
  var selectedUniqueKey = this.currentUniqueKey_;

  // Discard, since another load has been invoked after this one.
  if (selectedUniqueKey != this.currentUniqueKey_)
    return;

  this.loadItem_(
      selectedItem,
      new ImageView.Effect.Slide(step, this.isSlideshowPlaying_()),
      function() {} /* no displayCallback */,
      function(loadType, delay) {
        // Discard, since another load has been invoked after this one.
        if (selectedUniqueKey != this.currentUniqueKey_)
          return;
        if (shouldPrefetch(loadType, step, this.sequenceLength_))
          this.requestPrefetch(step, delay);
        if (this.isSlideshowPlaying_())
          this.scheduleNextSlide_();
      }.bind(this));
};

/**
 * Unload the current image.
 *
 * @param {ImageRect=} opt_zoomToRect Rectangle for zoom effect.
 * @private
 */
SlideMode.prototype.unloadImage_ = function(opt_zoomToRect) {
  this.imageView_.unload(opt_zoomToRect);
};

/**
 * Data model 'splice' event handler.
 * @param {!Event} event Event.
 * @this {SlideMode}
 * @private
 */
SlideMode.prototype.onSplice_ = function(event) {
  ImageUtil.setAttribute(this.arrowBox_, 'active', this.getItemCount_() > 1);

  // Splice invalidates saved indices, drop the saved selection.
  this.savedSelection_ = null;

  if (event.removed.length != 1)
    return;

  // Delay the selection to let the ribbon splice handler work first.
  setTimeout(function() {
    if (this.dataModel_.length === 0) {
      // No items left. Unload the image, disable edit and print button, and
      // show the banner.
      this.commitItem_(function() {
        this.unloadImage_();
        this.printButton_.disabled = true;
        this.editButton_.disabled = true;
        this.errorBanner_.show('GALLERY_NO_IMAGES');
        if (this.isEditing())
          this.toggleEditor();
      }.bind(this));
      return;
    }

    var displayedItemNotRemvoed = event.removed.every(function(item) {
      return item !== this.displayedItem_;
    }.bind(this));
    if (!displayedItemNotRemvoed) {
      // There is the next item, select it. Otherwise, select the last item.
      var nextIndex = Math.min(event.index, this.dataModel_.length - 1);
      // To force to dispatch a selection change event, unselect all before.
      this.selectionModel_.unselectAll();
      this.select(nextIndex);
      // If the removed image was edit, leave the editing mode.
      if (this.isEditing())
        this.toggleEditor();
    }
  }.bind(this), 0);
};

/**
 * @param {number} direction -1 for left, 1 for right.
 * @return {number} Next index in the given direction, with wrapping.
 * @private
 */
SlideMode.prototype.getNextSelectedIndex_ = function(direction) {
  function advance(index, limit) {
    index += (direction > 0 ? 1 : -1);
    if (index < 0)
      return limit - 1;
    if (index === limit)
      return 0;
    return index;
  }

  // If the saved selection is multiple the Slideshow should cycle through
  // the saved selection.
  if (this.isSlideshowOn_() &&
      this.savedSelection_ && this.savedSelection_.length > 1) {
    var pos = advance(this.savedSelection_.indexOf(this.getSelectedIndex()),
        this.savedSelection_.length);
    return this.savedSelection_[pos];
  } else {
    return advance(this.getSelectedIndex(), this.getItemCount_());
  }
};

/**
 * Advance the selection based on the pressed key ID.
 * @param {string} keyID Key identifier.
 */
SlideMode.prototype.advanceWithKeyboard = function(keyID) {
  var prev = (keyID === 'Up' ||
              keyID === 'Left' ||
              keyID === 'MediaPreviousTrack');
  this.advanceManually(prev ? -1 : 1);
};

/**
 * Advance the selection as a result of a user action (as opposed to an
 * automatic change in the slideshow mode).
 * @param {number} direction -1 for left, 1 for right.
 */
SlideMode.prototype.advanceManually = function(direction) {
  if (this.isSlideshowPlaying_())
    this.pauseSlideshow_();
  cr.dispatchSimpleEvent(this, 'useraction');
  this.selectNext(direction);
};

/**
 * Select the next item.
 * @param {number} direction -1 for left, 1 for right.
 */
SlideMode.prototype.selectNext = function(direction) {
  this.select(this.getNextSelectedIndex_(direction), direction);
};

/**
 * Select the first item.
 */
SlideMode.prototype.selectFirst = function() {
  this.select(0);
};

/**
 * Select the last item.
 */
SlideMode.prototype.selectLast = function() {
  this.select(this.getItemCount_() - 1);
};

// Loading/unloading

/**
 * Load and display an item.
 *
 * @param {!GalleryItem} item Item.
 * @param {!ImageView.Effect} effect Transition effect object.
 * @param {function()} displayCallback Called when the image is displayed
 *     (which can happen before the image load due to caching).
 * @param {function(number, number)} loadCallback Called when the image is fully
 *     loaded.
 * @private
 */
SlideMode.prototype.loadItem_ = function(
    item, effect, displayCallback, loadCallback) {
  this.dimmableUIController_.setLoading(true);
  this.showProgressBar_(item);

  var loadDone = this.itemLoaded_.bind(this, item, loadCallback);

  var displayDone = function() {
    cr.dispatchSimpleEvent(this, 'image-displayed');
    displayCallback();
  }.bind(this);

  this.editor_.openSession(
      item,
      effect,
      this.saveCurrentImage_.bind(this, item),
      displayDone,
      loadDone);
};

/**
 * A callback function when the editor opens a editing session for an image.
 * @param {!GalleryItem} item Gallery item.
 * @param {function(number, number)} loadCallback Called when the image is fully
 *     loaded.
 * @param {number} loadType Load type.
 * @param {number} delay Delay.
 * @param {*=} opt_error Error.
 * @private
 */
SlideMode.prototype.itemLoaded_ = function(
    item, loadCallback, loadType, delay, opt_error) {
  var entry = item.getEntry();

  this.hideProgressBar_();
  this.dimmableUIController_.setLoading(false);

  if (loadType === ImageView.LoadType.ERROR) {
    // if we have a specific error, then display it
    if (opt_error) {
      this.errorBanner_.show(/** @type {string} */ (opt_error));
    } else {
      // otherwise try to infer general error
      this.errorBanner_.show('GALLERY_IMAGE_ERROR');
    }
  } else if (loadType === ImageView.LoadType.OFFLINE) {
    this.errorBanner_.show('GALLERY_IMAGE_OFFLINE');
  }

  ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('View'));

  var toMillions = function(number) {
    return Math.round(number / (1000 * 1000));
  };

  var metadata = item.getMetadataItem();
  if (metadata) {
    ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MB'),
        toMillions(metadata.size));
  }

  var image = this.imageView_.getImage();
  ImageUtil.metrics.recordSmallCount(ImageUtil.getMetricName('Size.MPix'),
      toMillions(image.width * image.height));

  var extIndex = entry.name.lastIndexOf('.');
  var ext = extIndex < 0 ? '' :
      entry.name.substr(extIndex + 1).toLowerCase();
  if (ext === 'jpeg') ext = 'jpg';
  ImageUtil.metrics.recordEnum(
      ImageUtil.getMetricName('FileType'), ext, ImageUtil.FILE_TYPES);

  // Enable or disable buttons for editing and printing.
  if (opt_error) {
    this.editButton_.disabled = true;
    this.printButton_.disabled = true;
  } else {
    this.editButton_.disabled = false;
    this.printButton_.disabled = false;
  }

  // Saved label is hidden by default.
  this.savedLabel_.hidden = true;

  // Disable overwrite original checkbox until settings is loaded.
  this.overwriteOriginalCheckbox_.disabled = true;
  this.overwriteOriginalCheckbox_.checked = false;

  var keys = {};
  keys[SlideMode.OVERWRITE_ORIGINAL_KEY] = true;
  chrome.storage.local.get(keys,
      function(values) {
        // Users can overwrite original file only if loaded image is original
        // and writable.
        if (item.isOriginal() &&
            item.isWritableFile(this.volumeManager_)) {
          this.overwriteOriginalCheckbox_.disabled = false;
          this.overwriteOriginalCheckbox_.checked =
              values[SlideMode.OVERWRITE_ORIGINAL_KEY];
        }
      }.bind(this));

  loadCallback(loadType, delay);
};

/**
 * Commit changes to the current item and reset all messages/indicators.
 *
 * @param {function()} callback Callback.
 * @private
 */
SlideMode.prototype.commitItem_ = function(callback) {
  this.showSpinner_(false);
  this.errorBanner_.clear();
  this.editor_.getPrompt().hide();
  this.editor_.closeSession(callback);
};

/**
 * Request a prefetch for the next image.
 *
 * @param {number} direction -1 or 1.
 * @param {number} delay Delay in ms. Used to prevent the CPU-heavy image
 *   loading from disrupting the animation that might be still in progress.
 */
SlideMode.prototype.requestPrefetch = function(direction, delay) {
  if (this.getItemCount_() <= 1) return;

  var index = this.getNextSelectedIndex_(direction);
  this.imageView_.prefetch(assert(this.getItem(index)), delay);
};

// Event handlers.

/**
 * Click handler for the entire document.
 * @param {!Event} event Mouse click event.
 * @private
 */
SlideMode.prototype.onDocumentClick_ = function(event) {
  // Events created in fakeMouseClick in test util don't pass this test.
  if (!window.IN_TEST)
    event = assertInstanceof(event, MouseEvent);

  var targetElement = assertInstanceof(event.target, HTMLElement);
  // Close the bubble if clicked outside of it and if it is visible.
  if (!this.bubble_.contains(targetElement) &&
      !this.editButton_.contains(targetElement) &&
      !this.arrowLeft_.contains(targetElement) &&
      !this.arrowRight_.contains(targetElement) &&
      !this.bubble_.hidden) {
    this.bubble_.hidden = true;
  }
};

/**
 * Keydown handler.
 *
 * @param {!Event} event Event.
 * @return {boolean} True if handled.
 */
SlideMode.prototype.onKeyDown = function(event) {
  var keyID = util.getKeyModifiers(event) + event.keyIdentifier;

  if (this.isSlideshowOn_()) {
    switch (keyID) {
      case 'U+001B':  // Escape
      case 'MediaStop':
        this.stopSlideshow_(event);
        break;

      case 'U+0020':  // Space pauses/resumes the slideshow.
      case 'MediaPlayPause':
        this.toggleSlideshowPause_();
        break;

      case 'Up':
      case 'Down':
      case 'Left':
      case 'Right':
      case 'MediaNextTrack':
      case 'MediaPreviousTrack':
        this.advanceWithKeyboard(keyID);
        break;
    }
    return true;  // Consume all keystrokes in the slideshow mode.
  }

  // Handles shortcut keys common for both modes (editing and not-editing).
  switch (keyID) {
    case 'Ctrl-U+0050':  // Ctrl+'p' prints the current image.
      if (!this.printButton_.disabled)
        this.print_();
      return true;

    case 'U+0045':  // 'e' toggles the editor.
      if (!this.editButton_.disabled)
        this.toggleEditor(event);
      return true;
  }

  // Handles shortcurt keys for editing mode.
  if (this.isEditing()) {
    if (this.editor_.onKeyDown(event))
      return true;

    if (keyID === 'U+001B') { // Escape
      this.toggleEditor(event);
      return true;
    }

    return false;
  }

  // Handles shortcut keys for not-editing mode.
  switch (keyID) {
    case 'U+001B':  // Escape
      if (this.viewport_.isZoomed()) {
        this.viewport_.resetView();
        this.touchHandlers_.stopOperation();
        this.imageView_.applyViewportChange();
        return true;
      }
      break;

    case 'Home':
      this.selectFirst();
      return true;

    case 'End':
      this.selectLast();
      return true;

    case 'Up':
    case 'Down':
    case 'Left':
    case 'Right':
      if (this.viewport_.isZoomed()) {
        var delta = SlideMode.KEY_OFFSET_MAP[keyID];
        this.viewport_.setOffset(
            ~~(this.viewport_.getOffsetX() +
               delta[0] * this.viewport_.getZoom()),
            ~~(this.viewport_.getOffsetY() +
               delta[1] * this.viewport_.getZoom()));
        this.touchHandlers_.stopOperation();
        this.imageView_.applyViewportChange();
      } else {
        this.advanceWithKeyboard(keyID);
      }
      return true;

    case 'MediaNextTrack':
    case 'MediaPreviousTrack':
      this.advanceWithKeyboard(keyID);
      return true;

    case 'Ctrl-U+00BB':  // Ctrl+'=' zoom in.
      this.viewport_.zoomIn();
      this.touchHandlers_.stopOperation();
      this.imageView_.applyViewportChange();
      return true;

    case 'Ctrl-U+00BD':  // Ctrl+'-' zoom out.
      this.viewport_.zoomOut();
      this.touchHandlers_.stopOperation();
      this.imageView_.applyViewportChange();
      return true;

    case 'Ctrl-U+0030': // Ctrl+'0' zoom reset.
      this.viewport_.setZoom(1.0);
      this.touchHandlers_.stopOperation();
      this.imageView_.applyViewportChange();
      return true;
  }

  return false;
};

/**
 * Resize handler.
 * @private
 */
SlideMode.prototype.onResize_ = function() {
  this.touchHandlers_.stopOperation();
};

/**
 * Handles resize event of viewport.
 * @private
 */
SlideMode.prototype.onViewportResize_ = function() {
  // This method must be called after the resize of viewport.
  this.editor_.getBuffer().draw();
};

/**
 * Update thumbnails.
 */
SlideMode.prototype.updateThumbnails = function() {
  this.ribbon_.reset();
  if (this.active_)
    this.ribbon_.redraw();
};

// Saving

/**
 * Save the current image to a file.
 *
 * @param {!GalleryItem} item Item to save the image.
 * @param {function()} callback Callback.
 * @private
 */
SlideMode.prototype.saveCurrentImage_ = function(item, callback) {
  this.showSpinner_(true);

  var savedPromise = this.dataModel_.saveItem(
      this.volumeManager_,
      item,
      ImageUtil.ensureCanvas(this.imageView_.getImage()),
      this.overwriteOriginalCheckbox_.checked);

  savedPromise.then(function() {
    this.showSpinner_(false);
    this.flashSavedLabel_();

    // Record UMA for the first edit.
    if (this.imageView_.getContentRevision() === 1)
      ImageUtil.metrics.recordUserAction(ImageUtil.getMetricName('Edit'));

    // Users can change overwrite original setting only if there is no undo
    // stack and item is original and writable.
    var ableToChangeOverwriteOriginalSetting = !this.editor_.canUndo() &&
        item.isOriginal() && item.isWritableFile(this.volumeManager_);
    this.overwriteOriginalCheckbox_.disabled =
        !ableToChangeOverwriteOriginalSetting;

    callback();
  }.bind(this)).catch(function(error) {
    console.error(error.stack || error);

    this.showSpinner_(false);
    this.errorBanner_.show('GALLERY_SAVE_FAILED');

    callback();
  }.bind(this));
};

/**
 * Flash 'Saved' label briefly to indicate that the image has been saved.
 * @private
 */
SlideMode.prototype.flashSavedLabel_ = function() {
  this.savedLabel_.hidden = false;
  var setLabelHighlighted =
      ImageUtil.setAttribute.bind(null, this.savedLabel_, 'highlighted');
  setTimeout(setLabelHighlighted.bind(null, true), 0);
  setTimeout(setLabelHighlighted.bind(null, false), 300);
};

/**
 * Local storage key for the number of times that
 * the overwrite info bubble has been displayed.
 * @const {string}
 */
SlideMode.OVERWRITE_BUBBLE_KEY = 'gallery-overwrite-bubble';

/**
 * Local storage key for overwrite original checkbox value.
 * @const {string}
 */
SlideMode.OVERWRITE_ORIGINAL_KEY = 'gallery-overwrite-original';

/**
 * Max number that the overwrite info bubble is shown.
 * @const {number}
 */
SlideMode.OVERWRITE_BUBBLE_MAX_TIMES = 5;

/**
 * Handles change event of overwrite original checkbox.
 * @private
 */
SlideMode.prototype.onOverwriteOriginalCheckboxChanged_ = function() {
  var items = {};
  items[SlideMode.OVERWRITE_ORIGINAL_KEY] =
      this.overwriteOriginalCheckbox_.checked;
  chrome.storage.local.set(items);
};

/**
 * Overwrite info bubble close handler.
 * @private
 */
SlideMode.prototype.onCloseBubble_ = function() {
  this.bubble_.hidden = true;
  this.setOverwriteBubbleCount_(SlideMode.OVERWRITE_BUBBLE_MAX_TIMES);
};

// Slideshow

/**
 * Slideshow interval in ms.
 */
SlideMode.SLIDESHOW_INTERVAL = 5000;

/**
 * First slideshow interval in ms. It should be shorter so that the user
 * is not guessing whether the button worked.
 */
SlideMode.SLIDESHOW_INTERVAL_FIRST = 1000;

/**
 * Empirically determined duration of the fullscreen toggle animation.
 */
SlideMode.FULLSCREEN_TOGGLE_DELAY = 500;

/**
 * @return {boolean} True if the slideshow is on.
 * @private
 */
SlideMode.prototype.isSlideshowOn_ = function() {
  return this.container_.hasAttribute('slideshow');
};

/**
 * Starts the slideshow.
 * @param {number=} opt_interval First interval in ms.
 * @param {Event=} opt_event Event.
 */
SlideMode.prototype.startSlideshow = function(opt_interval, opt_event) {
  // Reset zoom.
  this.viewport_.resetView();
  this.imageView_.applyViewportChange();

  // Disable touch operation.
  this.touchHandlers_.enabled = false;

  // Set the attribute early to prevent the toolbar from flashing when
  // the slideshow is being started from the mosaic view.
  this.container_.setAttribute('slideshow', 'playing');

  if (this.active_) {
    this.stopEditing_();
  } else {
    // We are in the Mosaic mode. Toggle the mode but remember to return.
    this.leaveAfterSlideshow_ = true;

    // Wait until the zoom animation from the mosaic mode is done.
    var startSlideshowAfterTransition = function() {
      setTimeout(function() {
        this.startSlideshow.call(this, SlideMode.SLIDESHOW_INTERVAL, opt_event);
      }.bind(this), ImageView.MODE_TRANSITION_DURATION);
    }.bind(this);
    this.toggleMode_(startSlideshowAfterTransition);
    return;
  }

  if (opt_event)  // Caused by user action, notify the Gallery.
    cr.dispatchSimpleEvent(this, 'useraction');

  this.fullscreenBeforeSlideshow_ = util.isFullScreen(this.context_.appWindow);
  if (!this.fullscreenBeforeSlideshow_) {
    this.toggleFullScreen_();
    opt_interval = (opt_interval || SlideMode.SLIDESHOW_INTERVAL) +
        SlideMode.FULLSCREEN_TOGGLE_DELAY;
  }

  // These are workarounds. Mouseout event is not dispatched when window becomes
  // fullscreen and cursor gets out of the element
  // TODO(yawano): Find better implementation.
  this.dimmableUIController_.setCursorOutOfTools();
  document.querySelector('files-tooltip').hideTooltip();

  this.resumeSlideshow_(opt_interval);

  this.setSubMode_(Gallery.SubMode.SLIDESHOW);
};

/**
 * Stops the slideshow.
 * @param {Event=} opt_event Event.
 * @private
 */
SlideMode.prototype.stopSlideshow_ = function(opt_event) {
  if (!this.isSlideshowOn_())
    return;

  if (opt_event)  // Caused by user action, notify the Gallery.
    cr.dispatchSimpleEvent(this, 'useraction');

  this.pauseSlideshow_();
  this.container_.removeAttribute('slideshow');

  // Do not restore fullscreen if we exited fullscreen while in slideshow.
  var fullscreen = util.isFullScreen(this.context_.appWindow);
  var toggleModeDelay = 0;
  if (!this.fullscreenBeforeSlideshow_ && fullscreen) {
    this.toggleFullScreen_();
    toggleModeDelay = SlideMode.FULLSCREEN_TOGGLE_DELAY;
  }
  if (this.leaveAfterSlideshow_) {
    this.leaveAfterSlideshow_ = false;
    setTimeout(this.toggleMode_.bind(this), toggleModeDelay);
  }

  // Re-enable touch operation.
  this.touchHandlers_.enabled = true;

  this.setSubMode_(Gallery.SubMode.BROWSE);
};

/**
 * @return {boolean} True if the slideshow is playing (not paused).
 * @private
 */
SlideMode.prototype.isSlideshowPlaying_ = function() {
  return this.container_.getAttribute('slideshow') === 'playing';
};

/**
 * Pauses/resumes the slideshow.
 * @private
 */
SlideMode.prototype.toggleSlideshowPause_ = function() {
  cr.dispatchSimpleEvent(this, 'useraction');  // Show the tools.
  if (this.isSlideshowPlaying_()) {
    this.pauseSlideshow_();
  } else {
    this.resumeSlideshow_(SlideMode.SLIDESHOW_INTERVAL_FIRST);
  }
};

/**
 * @param {number=} opt_interval Slideshow interval in ms.
 * @private
 */
SlideMode.prototype.scheduleNextSlide_ = function(opt_interval) {
  console.assert(this.isSlideshowPlaying_(), 'Inconsistent slideshow state');

  if (this.slideShowTimeout_)
    clearTimeout(this.slideShowTimeout_);

  this.slideShowTimeout_ = setTimeout(function() {
    this.slideShowTimeout_ = null;
    this.selectNext(1);
  }.bind(this), opt_interval || SlideMode.SLIDESHOW_INTERVAL);
};

/**
 * Resumes the slideshow.
 * @param {number=} opt_interval Slideshow interval in ms.
 * @private
 */
SlideMode.prototype.resumeSlideshow_ = function(opt_interval) {
  this.container_.setAttribute('slideshow', 'playing');
  this.scheduleNextSlide_(opt_interval);
};

/**
 * Pauses the slideshow.
 * @private
 */
SlideMode.prototype.pauseSlideshow_ = function() {
  this.container_.setAttribute('slideshow', 'paused');
  if (this.slideShowTimeout_) {
    clearTimeout(this.slideShowTimeout_);
    this.slideShowTimeout_ = null;
  }
};

/**
 * @return {boolean} True if the editor is active.
 */
SlideMode.prototype.isEditing = function() {
  return this.container_.hasAttribute('editing');
};

/**
 * Stops editing.
 * @private
 */
SlideMode.prototype.stopEditing_ = function() {
  if (this.isEditing())
    this.toggleEditor();
};

/**
 * Sets current sub mode.
 * @param {Gallery.SubMode} subMode
 * @private
 */
SlideMode.prototype.setSubMode_ = function(subMode) {
  if (this.subMode_ === subMode)
    return;

  this.subMode_ = subMode;

  var event = new Event('sub-mode-change');
  event.subMode = this.subMode_;
  this.dispatchEvent(event);
};

/**
 * Returns current sub mode.
 * @return {Gallery.SubMode}
 */
SlideMode.prototype.getSubMode = function() {
  return this.subMode_;
};

/**
 * Activate/deactivate editor.
 * @param {Event=} opt_event Event.
 */
SlideMode.prototype.toggleEditor = function(opt_event) {
  if (opt_event)  // Caused by user action, notify the Gallery.
    cr.dispatchSimpleEvent(this, 'useraction');

  if (!this.active_) {
    this.toggleMode_(this.toggleEditor.bind(this));
    return;
  }

  this.stopSlideshow_();

  ImageUtil.setAttribute(this.container_, 'editing', !this.isEditing());
  this.editButtonToggleRipple_.activated = this.isEditing();

  if (this.isEditing()) { // isEditing has just been flipped to a new value.
    // Reset zoom.
    this.viewport_.resetView();

    // Scale the screen so that it doesn't overlap the toolbars.
    this.viewport_.setScreenTop(ImageEditor.Toolbar.HEIGHT);
    this.viewport_.setScreenBottom(ImageEditor.Toolbar.HEIGHT);

    this.imageView_.applyViewportChange();

    this.touchHandlers_.enabled = false;

    // Show editor warning message.
    SlideMode.getEditorWarningMessage(
        assert(this.getItem(this.getSelectedIndex())),
        this.context_.readonlyDirName,
        assert(this.dataModel_.fallbackSaveDirectory)
        ).then(function(warningMessage) {
      if (!warningMessage)
        return;

      this.filesToast_.show(warningMessage);
    }.bind(this));

    // Show overwrite original bubble if it hasn't been shown for max times.
    this.getOverwriteBubbleCount_().then(function(count) {
      if (count >= SlideMode.OVERWRITE_BUBBLE_MAX_TIMES)
        return;

      this.setOverwriteBubbleCount_(count + 1);
      this.bubble_.hidden = false;
    }.bind(this));

    this.setSubMode_(Gallery.SubMode.EDIT);
  } else {
    this.editor_.getPrompt().hide();
    this.editor_.leaveMode(false /* not to switch mode */);

    this.viewport_.setScreenTop(0);
    this.viewport_.setScreenBottom(0);
    this.imageView_.applyViewportChange();

    this.bubble_.hidden = true;

    this.touchHandlers_.enabled = true;

    this.setSubMode_(Gallery.SubMode.BROWSE);
  }
};

/**
 * Gets count of overwrite bubble.
 * @return {!Promise<number>}
 * @private
 */
SlideMode.prototype.getOverwriteBubbleCount_ = function() {
  return new Promise(function(resolve, reject) {
    var requests = {};
    requests[SlideMode.OVERWRITE_BUBBLE_KEY] = 0;

    chrome.storage.local.get(requests, function(results) {
      if (!!chrome.runtime.lastError) {
        reject(chrome.runtime.lastError);
        return;
      }

      resolve(results[SlideMode.OVERWRITE_BUBBLE_KEY]);
    });
  });
};

/**
 * Sets count of overwrite bubble.
 * @param {number} value
 * @private
 */
SlideMode.prototype.setOverwriteBubbleCount_ = function(value) {
  var requests = {};
  requests[SlideMode.OVERWRITE_BUBBLE_KEY] = value;
  chrome.storage.local.set(requests);
};

/**
 * Prints the current item.
 * @private
 */
SlideMode.prototype.print_ = function() {
  cr.dispatchSimpleEvent(this, 'useraction');
  window.print();
};

/**
 * Shows progress bar.
 * @param {!GalleryItem} item
 * @private
 */
SlideMode.prototype.showProgressBar_ = function(item) {
  this.loadingItemUrl_ = item.getEntry().toURL();

  if (this.progressBarTimer_ !== 0) {
    clearTimeout(this.progressBarTimer_);
    this.progressBarTimer_ = 0;
  }

  this.progressBar_.setAttribute('indeterminate', true);

  this.progressBarTimer_ = setTimeout(function() {
    this.progressBar_.hidden = false;
  }.bind(this), 1000);
};

/**
 * Hides progress bar.
 * @private
 */
SlideMode.prototype.hideProgressBar_ = function() {
  if (this.progressBarTimer_ !== 0) {
    clearTimeout(this.progressBarTimer_);
    this.progressBarTimer_ = 0;
  }

  this.loadingItemUrl_ = undefined;

  this.progressBar_.hidden = true;
};

/**
 * Updates progress bar.
 * @param {!FileTransferStatus} status
 * @private
 */
SlideMode.prototype.updateProgressBar_ = function(status) {
  if (status.fileUrl !== this.loadingItemUrl_ ||
      status.num_total_jobs !== 1) {
    // If user starts to download another image (or file), we cannot show
    // determinate progress bar anymore since total and processed are for all
    // current downloads.
    this.progressBar_.setAttribute('indeterminate', true);
    return;
  }

  // Progress begins from 5%.
  var progress = 5 + (95 * status.processed / status.total);

  this.progressBar_.removeAttribute('indeterminate');
  this.progressBar_.value = progress;
};

/**
 * Shows/hides the busy spinner.
 *
 * @param {boolean} on True if show, false if hide.
 * @private
 */
SlideMode.prototype.showSpinner_ = function(on) {
  if (this.spinnerTimer_) {
    clearTimeout(this.spinnerTimer_);
    this.spinnerTimer_ = null;
  }

  if (on) {
    this.spinnerTimer_ = setTimeout(function() {
      this.spinnerTimer_ = null;
      ImageUtil.setAttribute(this.container_, 'spinner', true);
    }.bind(this), 1000);
  } else {
    ImageUtil.setAttribute(this.container_, 'spinner', false);
  }
};

/**
 * Apply the change of viewport.
 */
SlideMode.prototype.applyViewportChange = function() {
  this.imageView_.applyViewportChange();
};

/**
 * Touch handlers of the slide mode.
 * @param {!Element} targetElement Event source.
 * @param {!SlideMode} slideMode Slide mode to be operated by the handler.
 * @struct
 * @constructor
 */
function TouchHandler(targetElement, slideMode) {
  /**
   * Event source.
   * @type {!Element}
   * @private
   * @const
   */
  this.targetElement_ = targetElement;

  /**
   * Target of touch operations.
   * @type {!SlideMode}
   * @private
   * @const
   */
  this.slideMode_ = slideMode;

  /**
   * Flag to enable/disable touch operation.
   * @type {boolean}
   * @private
   */
  this.enabled_ = true;

  /**
   * Whether it is in a touch operation that is started from targetElement or
   * not.
   * @type {boolean}
   * @private
   */
  this.touchStarted_ = false;

  /**
   * Whether the element is being clicked now or not.
   * @type {boolean}
   * @private
   */
  this.clickStarted_ = false;

  /**
   * The swipe action that should happen only once in an operation is already
   * done or not.
   * @type {boolean}
   * @private
   */
  this.done_ = false;

  /**
   * Event on beginning of the current gesture.
   * The variable is updated when the number of touch finger changed.
   * @type {TouchEvent}
   * @private
   */
  this.gestureStartEvent_ = null;

  /**
   * Rotation value on beginning of the current gesture.
   * @type {number}
   * @private
   */
  this.gestureStartRotation_ = 0;

  /**
   * Last touch event.
   * @type {TouchEvent}
   * @private
   */
  this.lastEvent_ = null;

  /**
   * Zoom value just after last touch event.
   * @type {number}
   * @private
   */
  this.lastZoom_ = 1.0;

  targetElement.addEventListener('touchstart', this.onTouchStart_.bind(this));
  var onTouchEventBound = this.onTouchEvent_.bind(this);
  targetElement.ownerDocument.addEventListener('touchmove', onTouchEventBound);
  targetElement.ownerDocument.addEventListener('touchend', onTouchEventBound);

  targetElement.addEventListener('mousedown', this.onMouseDown_.bind(this));
  targetElement.ownerDocument.addEventListener('mousemove',
      this.onMouseMove_.bind(this));
  targetElement.ownerDocument.addEventListener('mouseup',
      this.onMouseUp_.bind(this));
  targetElement.addEventListener('mousewheel', this.onMouseWheel_.bind(this));
}

/**
 * If the user touched the image and moved the finger more than SWIPE_THRESHOLD
 * horizontally it's considered as a swipe gesture (change the current image).
 * @type {number}
 * @const
 */
TouchHandler.SWIPE_THRESHOLD = 100;

/**
 * Rotation threshold in degrees.
 * @type {number}
 * @const
 */
TouchHandler.ROTATION_THRESHOLD = 25;

/**
 * Obtains distance between fingers.
 * @param {!TouchEvent} event Touch event. It should include more than two
 *     touches.
 * @return {number} Distance between touch[0] and touch[1].
 */
TouchHandler.getDistance = function(event) {
  var touch1 = event.touches[0];
  var touch2 = event.touches[1];
  var dx = touch1.clientX - touch2.clientX;
  var dy = touch1.clientY - touch2.clientY;
  return Math.sqrt(dx * dx + dy * dy);
};

/**
 * Obtains the degrees of the pinch twist angle.
 * @param {!TouchEvent} event1 Start touch event. It should include more than
 *     two touches.
 * @param {!TouchEvent} event2 Current touch event. It should include more than
 *     two touches.
 * @return {number} Degrees of the pinch twist angle.
 */
TouchHandler.getTwistAngle = function(event1, event2) {
  var dx1 = event1.touches[1].clientX - event1.touches[0].clientX;
  var dy1 = event1.touches[1].clientY - event1.touches[0].clientY;
  var dx2 = event2.touches[1].clientX - event2.touches[0].clientX;
  var dy2 = event2.touches[1].clientY - event2.touches[0].clientY;
  var innerProduct = dx1 * dx2 + dy1 * dy2;  // |v1| * |v2| * cos(t) = x / r
  var outerProduct = dx1 * dy2 - dy1 * dx2;  // |v1| * |v2| * sin(t) = y / r
  return Math.atan2(outerProduct, innerProduct) * 180 / Math.PI;  // atan(y / x)
};

TouchHandler.prototype = /** @struct */ {
  /**
   * @param {boolean} flag New value.
   */
  set enabled(flag) {
    this.enabled_ = flag;
    if (!this.enabled_)
      this.stopOperation();
  }
};

/**
 * Stops the current touch operation.
 */
TouchHandler.prototype.stopOperation = function() {
  this.touchStarted_ = false;
  this.done_ = false;
  this.gestureStartEvent_ = null;
  this.lastEvent_ = null;
  this.lastZoom_ = 1.0;
};

/**
 * Handles touch start events.
 * @param {!Event} event Touch event.
 * @private
 */
TouchHandler.prototype.onTouchStart_ = function(event) {
  event = assertInstanceof(event, TouchEvent);
  if (this.enabled_ && event.touches.length === 1)
    this.touchStarted_ = true;
};

/**
 * Handles touch move and touch end events.
 * @param {!Event} event Touch event.
 * @private
 */
TouchHandler.prototype.onTouchEvent_ = function(event) {
  event = assertInstanceof(event, TouchEvent);
  // Check if the current touch operation started from the target element or
  // not.
  if (!this.touchStarted_)
    return;

  // Check if the current touch operation ends with the event.
  if (event.touches.length === 0) {
    this.stopOperation();
    return;
  }

  // Check if a new gesture started or not.
  var viewport = this.slideMode_.getViewport();
  if (!this.lastEvent_ ||
      this.lastEvent_.touches.length !== event.touches.length) {
    if (event.touches.length === 2 ||
        event.touches.length === 1) {
      this.gestureStartEvent_ = event;
      this.gestureStartRotation_ = viewport.getRotation();
      this.lastEvent_ = event;
      this.lastZoom_ = viewport.getZoom();
    } else {
      this.gestureStartEvent_ = null;
      this.gestureStartRotation_ = 0;
      this.lastEvent_ = null;
      this.lastZoom_ = 1.0;
    }
    return;
  }

  // Handle the gesture movement.
  switch (event.touches.length) {
    case 1:
      if (viewport.isZoomed()) {
        // Scrolling an image by swipe.
        var dx = event.touches[0].screenX - this.lastEvent_.touches[0].screenX;
        var dy = event.touches[0].screenY - this.lastEvent_.touches[0].screenY;
        viewport.setOffset(
            viewport.getOffsetX() + dx, viewport.getOffsetY() + dy);
        this.slideMode_.applyViewportChange();
      } else {
        // Traversing images by swipe.
        if (this.done_)
          break;
        var dx =
            event.touches[0].clientX -
            this.gestureStartEvent_.touches[0].clientX;
        if (dx > TouchHandler.SWIPE_THRESHOLD) {
          this.slideMode_.advanceManually(-1);
          this.done_ = true;
        } else if (dx < -TouchHandler.SWIPE_THRESHOLD) {
          this.slideMode_.advanceManually(1);
          this.done_ = true;
        }
      }
      break;

    case 2:
      // Pinch zoom.
      var distance1 = TouchHandler.getDistance(this.lastEvent_);
      var distance2 = TouchHandler.getDistance(event);
      if (distance1 === 0)
        break;
      var zoom = distance2 / distance1 * this.lastZoom_;
      viewport.setZoom(zoom);

      // Pinch rotation.
      assert(this.gestureStartEvent_);
      var angle = TouchHandler.getTwistAngle(this.gestureStartEvent_, event);
      if (angle > TouchHandler.ROTATION_THRESHOLD)
        viewport.setRotation(this.gestureStartRotation_ + 1);
      else if (angle < -TouchHandler.ROTATION_THRESHOLD)
        viewport.setRotation(this.gestureStartRotation_ - 1);
      else
        viewport.setRotation(this.gestureStartRotation_);
      this.slideMode_.applyViewportChange();
      break;
  }

  // Update the last event.
  this.lastEvent_ = event;
  this.lastZoom_ = viewport.getZoom();
};

/**
 * Zoom magnification of one scroll event.
 * @private {number}
 * @const
 */
TouchHandler.WHEEL_ZOOM_FACTOR = 1.05;

/**
 * Handles mouse wheel events.
 * @param {!Event} event Wheel event.
 * @private
 */
TouchHandler.prototype.onMouseWheel_ = function(event) {
  var event = assertInstanceof(event, MouseEvent);
  var viewport = this.slideMode_.getViewport();
  if (!this.enabled_)
    return;

  this.stopOperation();
  this.lastZoom_ = viewport.getZoom();
  var zoom = this.lastZoom_;
  if (event.wheelDeltaY > 0) {
    zoom *= TouchHandler.WHEEL_ZOOM_FACTOR;
  } else {
    zoom /= TouchHandler.WHEEL_ZOOM_FACTOR;
  }
  viewport.setZoom(zoom);
  this.slideMode_.imageView_.applyViewportChange();
};

/**
 * Handles mouse down events.
 * @param {!Event} event Wheel event.
 * @private
 */
TouchHandler.prototype.onMouseDown_ = function(event) {
  var event = assertInstanceof(event, MouseEvent);
  var viewport = this.slideMode_.getViewport();
  if (!this.enabled_ || event.button !== 0)
    return;
  this.clickStarted_ = true;
};

/**
 * Handles mouse move events.
 * @param {!Event} event Wheel event.
 * @private
 */
TouchHandler.prototype.onMouseMove_ = function(event) {
  var event = assertInstanceof(event, MouseEvent);
  var viewport = this.slideMode_.getViewport();
  if (!this.enabled_ || !this.clickStarted_)
    return;
  this.stopOperation();
  viewport.setOffset(
      viewport.getOffsetX() +
          (/** @type {{movementX: number}} */(event)).movementX,
      viewport.getOffsetY() +
          (/** @type {{movementY: number}} */(event)).movementY);
  this.slideMode_.imageView_.applyViewportChange();
};

/**
 * Handles mouse up events.
 * @param {!Event} event Wheel event.
 * @private
 */
TouchHandler.prototype.onMouseUp_ = function(event) {
  if (event.button !== 0)
    return;
  this.clickStarted_ = false;
};
